Repository navigation
Conversation
- Adds `AssetKind.attestation` for storing attestation bundles as `PackageVersionAsset` entities in Datastore. - In `packageBackend.publishUploadedBlob`, checks for accompanying `tmp/<guid>.sigstore.json` in the incoming bucket, reads it, and saves it as an attestation asset. - Exposes `GET /api/packages/<package>/versions/<version>/attestation` endpoint for client retrieval. - Updates `PubApiClientExt` and adds unit tests in `upload_test.dart`.
8d92052 to
8cae402
Compare
… UploadSignerService
- Revert attestationUrl and attestationFields in UploadInfo, UploadSignerService, and FakeUploadSignerService.
- Support POST requests on /api/packages/versions/newUploadFinish and /api/packages/versions/newUploadFinish/<uploadId> to receive {'attestation': <bundle>} JSON body.
- Update PackageBackend.publishUploadedBlob to accept attestationContent directly rather than reading a separate object from Cloud Storage.
- Expose Client and sendRaw in api_builder to facilitate sending raw and POST finalize requests.
- Update PubApiClientExt.uploadPackageBytes to send attestation bundle in POST finalization request.
- Run codegen and update tests.
'/api/packages/versions/new' now returns an 'attestationUrl' pointing at '/api/packages/versions/newUploadAttestation/<uploadId>', where the client POSTs the attestation bundle before uploading the archive. The bundle is stored in the incoming bucket next to the archive, and read, stored and (later) verified when the upload is finished. 'newUploadFinish' is a GET endpoint again. Also: * reject attestations over maxAttestationContentLength instead of silently truncating them with capContent(), as a truncated bundle could never be verified, and * validate that the upload id is a uuid before deriving an object name from it.
| if (attestationContent != null) | ||
| PackageVersionAsset.init( | ||
| package: key.package, | ||
| version: key.version, | ||
| kind: AssetKind.attestation, | ||
| versionCreated: versionCreated, | ||
| path: '${key.package}-${key.version}.sigstore.json', | ||
| // Note: not capped, a truncated attestation could never be verified. | ||
| // The length is checked when the attestation is uploaded. | ||
| textContent: attestationContent, | ||
| ), |
There was a problem hiding this comment.
Storing the attestation only as a PackageVersionAsset and serving it directly from the database in getPackageVersionAttestation has a few architectural issues:
- Canonical vs. derived storage:
PackageVersionAssetis for derived data extracted from the package tarball (derivePackageVersionEntitiesfor README, CHANGELOG, pubspec, license; seedoc/entities.md). Because the attestation bundle is uploaded alongside the tarball rather than inside it, it is canonical data. If we only store it inPackageVersionAssetand deletetmp/<guid>.attestation.jsonfrom_incomingBucket, it is never stored in_canonicalBucket(packages/<package>-<version>...), and re-runningderivePackageVersionEntitiesfrom the canonical archive would lose the attestation. It should be copied into_canonicalBucketalongside the tarball in_performTarballUpload. - Serving via
ExportedApi/ GCS:GET /api/packages/<package>/versions/<version>/attestationwill be called duringdart pub get(inpub#4875). All otherpub getendpoints (/api/packages/<pkg>,/api/packages/<pkg>/advisories,/api/archives/...) are exported to_exportedApiBucketviaExportedApi(app/lib/package/api_export/exported_api.dart) and served directly from GCS by GCLB (and exempted from Cloud Armor/api/rate limits). Exporting attestations viaExportedApialso meansApiExporter.synchronizePackagewill automatically remove exported attestations if a package or version is moderated (lookupPackageVersionAssetcurrently does not checkisPackageVisible/pv.isNotVisible). - Cloud Armor POST rate limit on upload: In production, Cloud Armor rate-limits non-GET requests to
pub.devto 5 requests per 3 minutes per IP (which wasn't hit bypub publishpreviously because the tarballPOSTgoes tostorage.googleapis.com, whilenewandnewUploadFinishonpub.devareGETrequests with higher per-route limits). SendingPOST /api/packages/versions/newUploadAttestation/<uploadId>topub.devwill hit that limit when CI runners publish >5 packages from a monorepo in 3 minutes, so we'll need to adjust our Cloud Armor rules (or upload to GCS).
| @EndPoint.get('/api/packages/<package>/versions/<version>/attestation') | ||
| Future<Response> getPackageVersionAttestation( | ||
| Request request, | ||
| String package, | ||
| String version, | ||
| ) async { | ||
| checkPackageVersionParams(package, version); | ||
| final asset = await packageBackend.lookupPackageVersionAsset( | ||
| package, | ||
| version, | ||
| AssetKind.attestation, | ||
| ); |
There was a problem hiding this comment.
If we keep a handler here (or as a fallback/redirect similar to fetchPackage), note that lookupPackageVersionAsset does not check whether the package or version has been moderated (isPackageVisible(package) / pv.isNotVisible), so it would still return 200 for moderated packages/versions rather than 404.
| Future<SuccessMessage> uploadPackageAttestation( | ||
| Request request, | ||
| String uploadId, | ||
| ) async { | ||
| final bytes = await request.read().expand((i) => i).toList(); | ||
| if (bytes.isEmpty) { | ||
| throw PackageRejectedException( | ||
| 'Invalid attestation bundle format: the request body is empty.', | ||
| ); | ||
| } | ||
| await packageBackend.uploadAttestation(uploadId, bytes); |
There was a problem hiding this comment.
await request.read().expand((i) => i).toList() reads the entire request stream into memory (and unboxes/reboxes every byte) before uploadAttestation checks bytes.length > maxAttestationContentLength.
We have ByteFolderExt.foldBytes() on Stream<List<int>> in app/lib/shared/utils.dart (which uses BytesBuilder(copy: false)). Consider adding an optional {int? maxSize} check to foldBytes (similar to BucketExt.readAsBytes in app/lib/shared/storage.dart) so we abort reading the stream as soon as it exceeds maxAttestationContentLength.
First PR in stack for package attestation support:
AssetKind.attestationfor storing attestation bundles asPackageVersionAssetentities in Datastore.GET /api/packages/versions/newnow returns anattestationUrl, pointing atPOST /api/packages/versions/newUploadAttestation/<uploadId>. Repositories that do not support publishing with attestations omit the property, which is howpubdetects support before uploading the archive (Support uploading package attestations during publish --from-archive pub#4876).maxAttestationContentLength(128 KB), and that it parses as a JSON object, and then stores it astmp/<guid>.attestation.jsonin the incoming bucket.packageBackend.publishUploadedBlob, reads that object if it is present, saves it as an attestation asset, and removes it together with the other temporary objects.newUploadFinishremains aGET-only endpoint.capContent(): oversized bundles are rejected when they are uploaded rather than silently truncated, since a truncated bundle could never be verified.GET /api/packages/<package>/versions/<version>/attestationendpoint for client retrieval.PubApiClientExtand adds unit tests inupload_test.dart.Since the attestation is uploaded to the app server, publishing with an attestation does not require a second signed upload policy, and therefore no extra
iam.signBlobcall per publish.Verifying the bundle against the uploaded archive (digest and provenance) follows in the next PR in the stack.